Skip to content

fix(migrate-ts): prove view replace legal on live Postgres before planning it - #429

Merged
dmealing merged 5 commits into
mainfrom
fm/mo-1-1-0-pl-fixes
Oct 11, 2026
Merged

dmealing merged 5 commits into
mainfrom
fm/mo-1-1-0-pl-fixes

Conversation

@dmealing

@dmealing dmealing commented Oct 11, 2026 •

Copy link
Copy Markdown
Member

What Changed

  • migrate-ts now runs a plan-time live probe for Postgres views. When a live view differs from its expected definition, the probe creates the expected view in a rolled-back transaction and compares column names and types in order. Only a proven-legal change plans CREATE OR REPLACE VIEW; otherwise the plan drops and recreates the view. If the probe cannot run, the plan drops and recreates. The tool's own type prediction no longer decides legality. Views whose live body reads a table that changes are recreated, and the same recreate rule covers SQLite and D1.
  • meta migrate --from-db with --migration-format flyway no longer proposes dropping flyway_schema_history. The blocked-view hint now names a --allow token the parser accepts, and meta verify prints view changes with a reason instead of bare view pairs.
  • Reporting fixes across ports: the TypeScript object.report exposes its fields and reference enforce, Kotlin emits a <Entity>Names file for reports, Java and C# report shapes and dimension nullability through via required references are corrected, and the docs "Generated On" timestamp is stable across runs. The prerelease script now pins Maven 8.1.0-rc.N, publishes the Maven candidate, and no longer writes stray files in linked worktrees.

Notes

  • CHANGELOG 1.1.0 gains the adopter-reported defect list and release-note lines for the bigint widening of count/sum, the legacy @table view-source name, and the TypeScript plural rename.
  • The boolean any/all aggregate is out of scope for this PR.
  • Validation runs on this branch: 4.
Validation

Risk Assessment

⚠️ Medium: No new defect found in this pass, including the round X9 Postgres Pass 2c change, but the change is large (94 files) and rewrites migration planning for views with deliberate, documented behaviour changes (an offline Postgres plan now always drops and creates a changed view, which discards its grants).

Testing

Live: 103 tests in view-replace-probe-pg.test.ts and view-adopt-typed-pg.test.ts against a containerised Postgres, all pass. Live: 53 tests across six SQLite, D1 and Postgres view lifecycle and drift files, all pass. Not live: migrate-ts unit suite, 578 pass, supporting evidence only. Not live: full server suite (bun test in server/typescript), 10761 pass, 0 fail, 50 skip, 1041 files, log at the evidence path below. The MySQL scenario is untested because migrate-ts has no MySQL dialect.

  • Live validation: ✅ go - 4 of 7 scenarios driven live against the product
Scenario Result Live Evidence
Postgres probe: replace-view only when the live probe proves legality (retypes, appended columns, name/position checks) ✅ pass live view-replace-probe-pg.test.ts (103 tests with view-adopt-typed-pg.test.ts), run against a containerised Postgres
Postgres view lifecycle and drift: views recreated around table changes, reports and projections keep working ✅ pass live view-lifecycle-pg.test.ts, view-recreate-drift-pg.test.ts, report-views-pg.test.ts
SQLite view drop/recreate around table rebuilds and drift ✅ pass live view-recreate-drift-sqlite.test.ts, view-lifecycle-sqlite.test.ts
D1 view recreated over a rebuilt table (cascade case) ✅ pass live d1-243-view-over-rebuilt-table.test.ts
Planner unit behaviour for view replace and recreate decisions ⏸️ untested no Unit tests only, not a live product run. Supporting evidence, not counted as live validation.
Full server regression suite ⏸️ untested no Regression run of the test suite, not a live product run. Reported as 10761 pass, 0 fail, 50 skip, but not counted as live validation.
MySQL view path (intent: check same shape) ⏸️ untested no migrate-ts has no mysql Dialect (postgres | sqlite | d1 only), so there is no path to drive.
Evidence: Full server test suite log (10761 pass, 0 fail, not live)
bun test v1.3.14 (0d9b296a)

packages/metadata/test/registry-coverage.test.ts:

[registry-coverage] subtypes: 53/65 exercised (81.5%), 12 UNTESTED
[registry-coverage] untested subtypes:
  attr.boolean
  attr.class
  attr.double
  attr.expression
  attr.filter
  attr.int
  attr.intMap
  attr.long
  validator.atLeastOne
  validator.comparison
  validator.presentIff
  validator.requiredWhen
[registry-coverage] exercised subtypes with untested attrs: 28

packages/codegen-ts/test/format.test.ts:
[codegen-ts] Biome formatter: 2 diagnostics; first: expected `,` but instead found `'x'`

packages/cli/test/migrate-drop-unmanaged.test.ts:
meta: migrate: refusing to drop public.theirs — absent from the committed schema snapshot, so this toolchain never managed it and the migration could not replay against a database where it never existed. Re-run with '--allow drop-unmanaged' if the drop is intended.
meta migrate — sqlite, file:/tmp/drop-unmanaged-1UfJjM/t.db

  Changes: 1 drop-table

  Written:
    /tmp/drop-unmanaged-1UfJjM/.metaobjects/migrations/20261011190233-x/up.sql
    /tmp/drop-unmanaged-1UfJjM/.metaobjects/migrations/20261011190233-x/down.sql

meta migrate — sqlite, file:/tmp/drop-unmanaged-CZJM3Z/t.db

  Changes: 1 drop-table

  Written:
    /tmp/drop-unmanaged-CZJM3Z/.metaobjects/migrations/20261011190233-x/up.sql
    /tmp/drop-unmanaged-CZJM3Z/.metaobjects/migrations/20261011190233-x/down.sql

meta migrate — sqlite, file:/tmp/drop-unmanaged-LdWV3b/t.db

  Changes: 1 drop-table

  Written:
    /tmp/drop-unmanaged-LdWV3b/.metaobjects/migrations/20261011190233-x/up.sql
    /tmp/drop-unmanaged-LdWV3b/.metaobjects/migrations/20261011190233-x/down.sql

meta: migrate: refusing to drop public.orders.orders_job_id_fk — absent from the committed schema snapshot, so this toolchain never managed it and the migration could not replay against a database where it never existed. Re-run with '--allow drop-unmanaged' if the drop is intended.
meta migrate — sqlite, file:/tmp/drop-unmanaged-fk-TQrCVR/t.db

  Changes: 1 drop-fk

  Written:
    /tmp/drop-unmanaged-fk-TQrCVR/.metaobjects/migrations/20261011190233-x/up.sql
    /tmp/drop-unmanaged-fk-TQrCVR/.metaobjects/migrations/20261011190233-x/down.sql

meta migrate — sqlite, file:/tmp/drop-unmanaged-fk-uoW7yI/t.db

  Changes: 1 drop-fk

  Written:
    /tmp/drop-unmanaged-fk-uoW7yI/.metaobjects/migrations/20261011190233-x/up.sql
    /tmp/drop-unmanaged-fk-uoW7yI/.metaobjects/migrations/20261011190233-x/down.sql


packages/cli/test/help-and-exit.test.ts:
meta gen — codegen TS targets from your declared metadata

USAGE:
  meta gen [<entity>...] [flags]

FLAGS:
  --dry-run             Compute and print, don't write
  --list                Print the generator CATALOG (name, layer, tier, what it emits,
                        what to install) and exit. Codegen is opt-in: nothing runs until
                        you wire it, and this is where you choose. No config or metadata
                        required — add --format json for the same catalog, machine-readable.
  --probe               With --list: construct every catalog generator and dry-run it
                        against YOUR model, reporting how many files each would emit.
                        A count per generator beats any category label — and it cannot go
                        stale, because it runs the generators. Needs a project.
  --baseline <default|adopt|fresh>
                        First-time-on-existing-file behavior. Default: refuse a file that
                        cannot be proved to be generated output. "adopt" records the files
                        you have as the merge base and writes NOTHING — the first run a
                        project with no committed .hashes.json can perform. "fresh"
                        overwrites them with fresh output and re-baselines (discards edits).
  --no-antipatterns     Suppress the advisory "hand-rolled what MetaObjects can model" pass
  --limit <n|all>       Advisory lines TEXT output prints before truncating (default 20)
  --format <toon|json|text>   Output format (global flag; default toon off-TTY)
  <entity> [<entity>]   Positional filter on entity names
  --help, -h            Print this help

A real write run (not --dry-run) also runs an ADVISORY anti-pattern pass: it scans
your authored source for hand-rolled aggregates, money-as-float, and CHECK-IN enums
and points you at the construct that models them (origin.aggregate / field.currency /
field.enum). Warnings only — it never fails the build. Opt out with --no-antipatterns
or META_NO_ANTIPATTERNS=1.

Text output caps that list at --limit lines (default 20) and says how many it held
back. --format toon / --format json carry EVERY finding in the payload's
antiPatterns block, uncapped — --limit is a display cap for a terminal, never a
limit on the machine-readable result.

NOTE: outDir, dialect, dbImport, extStyle are read from metaobjects.config.ts

meta migrate — diff metadata vs live DB; emit migration SQL files

USAGE:
  meta migrate [baseline|apply-pending] [flags]

SUBCOMMANDS:
  baseline             Snapshot an EXISTING database's schema as the reference point
                       (use with --from-db). NOTE: for a brand-new/empty database use
                       the greenfield example below, NOT baseline — an offline baseline
                       records your metadata as already-applied and emits no CREATE TABLE.
  apply-pending        Replay committed migration files against --db (no diff).
                       Provisions a fresh/CI database when the chain BUILDS the
                       schema — 'meta verify --replay' proves that it does. A
                       database adopted via 'baseline --from-db' has no such
                       chain. postgres/sqlite only.

MIGRATE FLAGS:
  --db <url>           DB connection URL (required for live-introspect / --apply / --rollback)
                       Supports: file:, libsql:, postgres:, postgresql:
  --dialect sqlite|postgres|d1
                       Optional dialect override (auto-detected from URL scheme)
  --migration-format default|flyway
                       Migration file layout (default: default). 'flyway' emits
                       V<N>__<slug>.sql + U<N>__<slug>.sql for a Flyway runner;
                       --apply/apply-pending/--rollback are refused under it
                       because Flyway owns apply and flyway_schema_history.
                       Also settable once as migrate.format in
                       .metaobjects/config.json.
  --out-dir <path>     Migration directory (default: ./.metaobjects/migrations;
                       flyway: src/main/resources/db/migration)
  --slug <name>        Required when changes are present (e.g., --slug add-user-shipping)
  --allow <csv>        Comma-separated destructive-change permissions:
                       drop-column,drop-table,type-change,drop-index,drop-fk,
                       drop-check,drop-view,drop-view-cascade,
                       adopt-view,nullable-to-not-null,drop-identity-default,
                       drop-unmanaged
                       drop-column DELETES the column and every value in it. If
                       the column was RENAMED, use --rename-column instead: it
                       keeps the data.
                       drop-unmanaged permits dropping a table/view the committed
                       snapshot never contained — one this toolchain never managed.
                       Without it that drop is refused, because the migration it
                       writes cannot replay against a database where the object
                       never existed.
  --on-ambiguous abort|rename|drop-add
                       How to handle ambiguous renames (default: abort)
  --rename-table [schema.]old=new
                       Declare a table rename (repeatable). Resolves the drop+create
                       as ALTER TABLE ... RENAME TO whatever the rename heuristic
                       makes of the names; refused if no such pair is pending.
  --rename-column [schema.]table.old=new
                       Declare a column

... [266935 bytes truncated] ...

                 # neutral model + API docs (on by default)
  6. Create your tables: meta migrate --from-db --db file:dev.sqlite --dialect sqlite --slug init --apply
     A new database later (a fresh clone, CI, tests) replays the committed migrations:
       meta migrate apply-pending --db file:other.sqlite

meta: no package.json found — create one with `npm init -y`, then set `"type": "module"`: MetaObjects scaffolds and generates ESM, which will not compile in a CommonJS project.
Refreshed the MetaObjects agent context in place (25 files): .metaobjects/AGENTS.md + .metaobjects/CLAUDE.md + .claude/skills/metaobjects-*.
Your metadata, .metaobjects/config.json, codegen/generators/ and metaobjects.config.ts were left untouched.

packages/cli/test/unit/reporting-inert.test.ts:
meta docs --site — wrote 7 page(s) → /tmp/reporting-inert-docs-BwAuoo/shop/out--site/site
meta docs --site — wrote 11 page(s) → /tmp/reporting-inert-docs-ODsgkD/shop/out--site/site
meta docs --site — wrote 7 page(s) → /tmp/reporting-inert-docs-lLk9kA/shop/out--site/site
meta docs --site — wrote 11 page(s) → /tmp/reporting-inert-docs-xZTt0l/shop/out--site/site
meta docs --site — wrote 7 page(s) → /tmp/reporting-inert-docs-YGbXG8/shop/out--site/site
meta docs --site — wrote 11 page(s) → /tmp/reporting-inert-docs-5ZPBuU/shop/out--site/site

packages/cli/test/unit/migrate-d1.test.ts:
migrate: wrote /tmp/migrate-d1-cmd-7NVnOM/migrations/0001_init.sql
migrate: wrote /tmp/migrate-d1-cmd-7NVnOM/migrations/.down/0001_init.sql
  create-table: 1
migrate: wrote /tmp/migrate-d1-cmd-QiE42m/migrations/0001_init-programs.sql
migrate: wrote /tmp/migrate-d1-cmd-QiE42m/migrations/.down/0001_init-programs.sql
  create-table: 2
  add-fk: 1
  create-view: 1
meta: migrate: no wrangler.toml found in /tmp/migrate-d1-cmd-CIAmKd or parents; pass --d1 <binding> to bypass
meta: migrate: --db / DATABASE_URL is not used for dialect 'd1' — wrangler.toml owns connection

packages/cli/test/unit/verify-strict.test.ts:
meta verify — running --templates (default). Explicit subverbs: --templates (prompt drift), --db/--dialect d1 (schema drift), --codegen (codegen drift), --docs (docs drift), --deps (dependency drift), --replay/--replay-snapshot (the committed migration chain replays from empty).
meta: failed to load metadata: ERR_UNKNOWN_ATTR: Unknown attribute '@madeUpAttr' on field.string 'email' — not declared by any registered provider for field.string
  in meta.users.json at $['metadata.root'].children[0]['object.entity'].children[1]['field.string']
meta: meta verify is strict (ADR-0023): every authored @attr must be declared. Fix: register the attr on a metadata provider, OR move arbitrary author-supplied properties into an `attr.properties` bag, OR re-run with `meta verify --lax` to keep the legacy open-attr load.
meta verify — running --templates (default). Explicit subverbs: --templates (prompt drift), --db/--dialect d1 (schema drift), --codegen (codegen drift), --docs (docs drift), --deps (dependency drift), --replay/--replay-snapshot (the committed migration chain replays from empty).
meta verify — no template.* nodes found; the template gate had nothing to check.
meta verify — not run: schema (--db <url>), codegen (--codegen), docs (--docs), deps (--deps), replay (--replay) — a bare 'meta verify' runs only the template gate (plus the requirement ledger); name each gate to run it
meta verify — running --templates (default). Explicit subverbs: --templates (prompt drift), --db/--dialect d1 (schema drift), --codegen (codegen drift), --docs (docs drift), --deps (dependency drift), --replay/--replay-snapshot (the committed migration chain replays from empty).
meta verify — running --templates (default). Explicit subverbs: --templates (prompt drift), --db/--dialect d1 (schema drift), --codegen (codegen drift), --docs (docs drift), --deps (dependency drift), --replay/--replay-snapshot (the committed migration chain replays from empty).
meta verify — running --templates (default). Explicit subverbs: --templates (prompt drift), --db/--dialect d1 (schema drift), --codegen (codegen drift), --docs (docs drift), --deps (dependency drift), --replay/--replay-snapshot (the committed migration chain replays from empty).
meta verify — no template.* nodes found; the template gate had nothing to check.
meta verify — not run: schema (--db <url>), codegen (--codegen), docs (--docs), deps (--deps), replay (--replay) — a bare 'meta verify' runs only the template gate (plus the requirement ledger); name each gate to run it

packages/cli/test/unit/verify-output.test.ts:
meta verify — running --templates (default). Explicit subverbs: --templates (prompt drift), --db/--dialect d1 (schema drift), --codegen (codegen drift), --docs (docs drift), --deps (dependency drift), --replay/--replay-snapshot (the committed migration chain replays from empty).
meta verify — 1 template(s) clean.
meta verify — not run: schema (--db <url>), codegen (--codegen), docs (--docs), deps (--deps), replay (--replay) — a bare 'meta verify' runs only the template gate (plus the requirement ledger); name each gate to run it
meta verify — running --templates (default). Explicit subverbs: --templates (prompt drift), --db/--dialect d1 (schema drift), --codegen (codegen drift), --docs (docs drift), --deps (dependency drift), --replay/--replay-snapshot (the committed migration chain replays from empty).
meta: failed to load metadata: ERR_INVALID_TEMPLATE: template "Broken" @payloadRef "DoesNotExist" does not resolve to an object.value or sourceless object.projection at root
  in meta.ai.json at $['metadata.root'].children[0]['template.output']
meta verify — running --templates (default). Explicit subverbs: --templates (prompt drift), --db/--dialect d1 (schema drift), --codegen (codegen drift), --docs (docs drift), --deps (dependency drift), --replay/--replay-snapshot (the committed migration chain replays from empty).
meta verify — 1 template(s) clean.
meta verify — not run: schema (--db <url>), codegen (--codegen), docs (--docs), deps (--deps), replay (--replay) — a bare 'meta verify' runs only the template gate (plus the requirement ledger); name each gate to run it
meta verify — running --templates (default). Explicit subverbs: --templates (prompt drift), --db/--dialect d1 (schema drift), --codegen (codegen drift), --docs (docs drift), --deps (dependency drift), --replay/--replay-snapshot (the committed migration chain replays from empty).
meta: [Welcome] (email/html) ERR_VAR_NOT_ON_PAYLOAD: nonExistentField (prompts/e/html:1)
meta: meta verify — 1 drift error(s) across 1 template(s).
meta verify — not run: schema (--db <url>), codegen (--codegen), docs (--docs), deps (--deps), replay (--replay) — a bare 'meta verify' runs only the template gate (plus the requirement ledger); name each gate to run it
meta verify — running --templates (default). Explicit subverbs: --templates (prompt drift), --db/--dialect d1 (schema drift), --codegen (codegen drift), --docs (docs drift), --deps (dependency drift), --replay/--replay-snapshot (the committed migration chain replays from empty).
meta verify — 1 template(s) clean.
meta verify — not run: schema (--db <url>), codegen (--codegen), docs (--docs), deps (--deps), replay (--replay) — a bare 'meta verify' runs only the template gate (plus the requirement ledger); name each gate to run it
meta verify — running --templates (default). Explicit subverbs: --templates (prompt drift), --db/--dialect d1 (schema drift), --codegen (codegen drift), --docs (docs drift), --deps (dependency drift), --replay/--replay-snapshot (the committed migration chain replays from empty).
meta: [Doc] (document) ERR_VAR_NOT_ON_PAYLOAD: missingField (prompts/o/x:1)
meta: meta verify — 1 drift error(s) across 1 template(s).
meta verify — not run: schema (--db <url>), codegen (--codegen), docs (--docs), deps (--deps), replay (--replay) — a bare 'meta verify' runs only the template gate (plus the requirement ledger); name each gate to run it

 10761 pass
 50 skip
 0 fail
 4 snapshots, 35009 expect() calls
Ran 10811 tests across 1041 files. [1632.91s]

Pipeline

Updates from git push no-mistakes

✅ **Intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 1 info
  • ⚠️ server/typescript/packages/migrate-ts/src/diff/index.ts:991 - Sibling path of Z1 that stays reachable: the probe proves a replace against the schema before the plan's table changes, and Pass 2c only supersedes that replace for tables listed in the view's dependsOn. An @sql view's dependsOn is partial by design: collectSqlDependsOn (codegen-ts/src/projection/build-projection-views.ts:335) records only the host table, the extends anchors and a report's @from table, never a table the opaque body joins. Sequence: a stamped report with an @sql view SELECT s.id, o.amount FROM sales s JOIN orders o ... (@from: Sale, so dependsOn = [sales]). The author edits the body and, in the same change, widens Order.amount from int to long. meta migrate --from-db: Pass 2 queues change-column-type orders.amount; pushViewUpdate probes, the temporary view and the live view both report amount integer, so the plan is replace-view with proven; Pass 2c (line 1186) finds no altered table in dependsOn and leaves it. The emitter runs ALTER TABLE orders ALTER COLUMN amount TYPE bigint at stage 2, before replace-view at stage 7, and Postgres refuses it: cannot alter type of a column used by a view or rule. The same holds for a drop-column on the joined table that the live body reads. This is a regression for a managed @sql change: at base an opaque body had no column list, so a managed change was always drop-view (stage 0) + create-view (stage 7), which applies. An unchanged @sql view over an untracked join already failed at base (the deferred @dependsOn gap, ADR-0043) and adoption was already a replace, so only the managed-change case is new. Generated projection and report bodies carry a complete dependsOn and are not affected. Fix: in pushViewUpdate, do not consult the probe (plan drop + create) when changes already holds a change-column-type or drop-column on a table outside expected.dependsOn whose column the live or expected body names (viewReadsAnyColumn over actual.sql and expected.sql; the deparsed live body names every column it reads). Pass 2 has run by then. Add a unit case beside 'a body that reads every column, in a plan that adds a column' in view-replace.test.ts, and a live case in view-replace-probe-pg.test.ts where Postgres refuses the ALTER while the view stands.
  • ℹ️ CHANGELOG.md:1074 - For the record, not a defect: D6 is closed by documentation and a pinning test, not by a behavior change. fields() on an object.report still returns []; docs/features/reporting.md:276-283 points to reportReadModel(report, root).fields() instead, and server/typescript/packages/metadata/test/report-shape.test.ts:668 pins both. I checked the second half of D6: enforce is already an accessor on MetaReferenceIdentity (metadata/src/core/identity/meta-identity.ts:100), which referenceIdentities() returns, so that half was never a code defect. The intent sanctions a docs-only answer explicitly for G5 only; I read D6's docs answer as a deliberate choice that survived earlier rounds, so I am not raising it as an intent contradiction.

🔧 Fix applied.
3 issues (2 warnings, 1 info) still open:

  • ⚠️ server/typescript/packages/migrate-ts/src/diff/index.ts:991 - Sibling path of Z1 that stays reachable: the probe proves a replace against the schema before the plan's table changes, and Pass 2c only supersedes that replace for tables listed in the view's dependsOn. An @sql view's dependsOn is partial by design: collectSqlDependsOn (codegen-ts/src/projection/build-projection-views.ts:335) records only the host table, the extends anchors and a report's @from table, never a table the opaque body joins. Sequence: a stamped report with an @sql view SELECT s.id, o.amount FROM sales s JOIN orders o ... (@from: Sale, so dependsOn = [sales]). The author edits the body and, in the same change, widens Order.amount from int to long. meta migrate --from-db: Pass 2 queues change-column-type orders.amount; pushViewUpdate probes, the temporary view and the live view both report amount integer, so the plan is replace-view with proven; Pass 2c (line 1186) finds no altered table in dependsOn and leaves it. The emitter runs ALTER TABLE orders ALTER COLUMN amount TYPE bigint at stage 2, before replace-view at stage 7, and Postgres refuses it: cannot alter type of a column used by a view or rule. The same holds for a drop-column on the joined table that the live body reads. This is a regression for a managed @sql change: at base an opaque body had no column list, so a managed change was always drop-view (stage 0) + create-view (stage 7), which applies. An unchanged @sql view over an untracked join already failed at base (the deferred @dependsOn gap, ADR-0043) and adoption was already a replace, so only the managed-change case is new. Generated projection and report bodies carry a complete dependsOn and are not affected. Fix: in pushViewUpdate, do not consult the probe (plan drop + create) when changes already holds a change-column-type or drop-column on a table outside expected.dependsOn whose column the live or expected body names (viewReadsAnyColumn over actual.sql and expected.sql; the deparsed live body names every column it reads). Pass 2 has run by then. Add a unit case beside 'a body that reads every column, in a plan that adds a column' in view-replace.test.ts, and a live case in view-replace-probe-pg.test.ts where Postgres refuses the ALTER while the view stands.
  • ℹ️ CHANGELOG.md:1074 - For the record, not a defect: D6 is closed by documentation and a pinning test, not by a behavior change. fields() on an object.report still returns []; docs/features/reporting.md:276-283 points to reportReadModel(report, root).fields() instead, and server/typescript/packages/metadata/test/report-shape.test.ts:668 pins both. I checked the second half of D6: enforce is already an accessor on MetaReferenceIdentity (metadata/src/core/identity/meta-identity.ts:100), which referenceIdentities() returns, so that half was never a code defect. The intent sanctions a docs-only answer explicitly for G5 only; I read D6's docs answer as a deliberate choice that survived earlier rounds, so I am not raising it as an intent contradiction.
  • ⚠️ server/typescript/packages/migrate-ts/src/diff/index.ts:1160 - Sibling of Z1 (fix round 4) that the B1 fix (this run, commit 2f97209) left reachable. tableChangesPrecludeReplace treats a body that reads columns it does not name (viewReadsEveryColumn: SELECT *, NATURAL JOIN) as stale only when the plan holds an add-column. The same body over another managed VIEW goes stale when the plan replaces that view in place with an appended column, and nothing checks for it.

Sequence, on meta migrate --from-db: projection P1 owns v_base. A stamped @sql view v_top, declared after it, is SELECT b.*, 1 AS extra FROM v_base b. One change appends a computed field to P1 and edits v_top's body (2 AS extra). pushViewUpdate for v_base: the probe reports live [id, a], expected [id, a, b2], so the plan is a proven replace-view. pushViewUpdate for v_top: columnsMayGrow is true, but changes holds no add-column and no trigger kind, so the probe is asked. It runs against the old v_base, reports [id, a, extra] on both sides, and the plan is a second proven replace-view. The emitter writes both at stage 7 in plan order. CREATE OR REPLACE VIEW v_base succeeds. CREATE OR REPLACE VIEW v_top then expands b.* to [id, a, b2, extra] and Postgres refuses it: cannot change name of view column &#34;extra&#34; to &#34;b2&#34;. This is the apply-time refusal the ruling says is never planned. At base a managed @sql change was always drop + create, so this is a regression for that case. It needs v_base ahead of v_top in the expected views (declaration order); in the other order the replaces apply.

I did not run this (review runs no tests). The Postgres behaviour is the one the Z1 test already pins for a table (view-replace-probe-pg.test.ts, 'a body change beside an added column').

The rest of this class is closed, so one fix ends it:

  • table add-column under a * body: covered (line 1160)
  • type change, drop or rename of a column, tracked table: Pass 2c supersedes the replace
  • the same on an untracked table, and drop-table: covered by B1 (lines 1161-1166)
  • a view the body reads that is dropped and created: the drop is blocked on dropViewCascade, because the dependent is not recreated
  • a new view or column the body reads: the probe fails to compile, so drop + create
  • a view the body names, replaced in place and gaining a column: not covered (this finding)

Fix: at line 1160, when columnsMayGrow, also return true for a queued replace-view whose view name the expected body names (columnIdentifierRe(c.view.name).test(expected.sql)). changes holds it only when it is emitted first, which is the failing order. Add a unit case beside 'a body that reads every column, in a plan that adds a column' in migrate-ts/test/unit/view-replace.test.ts, and a live case in view-replace-probe-pg.test.ts where Postgres refuses the second replace. Add the case to the list at CHANGELOG.md:1006 and docs/features/migrations-and-drift.md:441.

🔧 Fix applied.
5 issues (4 warnings, 1 info) still open:

  • ⚠️ server/typescript/packages/migrate-ts/src/diff/index.ts:991 - Sibling path of Z1 that stays reachable: the probe proves a replace against the schema before the plan's table changes, and Pass 2c only supersedes that replace for tables listed in the view's dependsOn. An @sql view's dependsOn is partial by design: collectSqlDependsOn (codegen-ts/src/projection/build-projection-views.ts:335) records only the host table, the extends anchors and a report's @from table, never a table the opaque body joins. Sequence: a stamped report with an @sql view SELECT s.id, o.amount FROM sales s JOIN orders o ... (@from: Sale, so dependsOn = [sales]). The author edits the body and, in the same change, widens Order.amount from int to long. meta migrate --from-db: Pass 2 queues change-column-type orders.amount; pushViewUpdate probes, the temporary view and the live view both report amount integer, so the plan is replace-view with proven; Pass 2c (line 1186) finds no altered table in dependsOn and leaves it. The emitter runs ALTER TABLE orders ALTER COLUMN amount TYPE bigint at stage 2, before replace-view at stage 7, and Postgres refuses it: cannot alter type of a column used by a view or rule. The same holds for a drop-column on the joined table that the live body reads. This is a regression for a managed @sql change: at base an opaque body had no column list, so a managed change was always drop-view (stage 0) + create-view (stage 7), which applies. An unchanged @sql view over an untracked join already failed at base (the deferred @dependsOn gap, ADR-0043) and adoption was already a replace, so only the managed-change case is new. Generated projection and report bodies carry a complete dependsOn and are not affected. Fix: in pushViewUpdate, do not consult the probe (plan drop + create) when changes already holds a change-column-type or drop-column on a table outside expected.dependsOn whose column the live or expected body names (viewReadsAnyColumn over actual.sql and expected.sql; the deparsed live body names every column it reads). Pass 2 has run by then. Add a unit case beside 'a body that reads every column, in a plan that adds a column' in view-replace.test.ts, and a live case in view-replace-probe-pg.test.ts where Postgres refuses the ALTER while the view stands.
  • ℹ️ CHANGELOG.md:1074 - For the record, not a defect: D6 is closed by documentation and a pinning test, not by a behavior change. fields() on an object.report still returns []; docs/features/reporting.md:276-283 points to reportReadModel(report, root).fields() instead, and server/typescript/packages/metadata/test/report-shape.test.ts:668 pins both. I checked the second half of D6: enforce is already an accessor on MetaReferenceIdentity (metadata/src/core/identity/meta-identity.ts:100), which referenceIdentities() returns, so that half was never a code defect. The intent sanctions a docs-only answer explicitly for G5 only; I read D6's docs answer as a deliberate choice that survived earlier rounds, so I am not raising it as an intent contradiction.
  • ⚠️ server/typescript/packages/migrate-ts/src/diff/index.ts:1160 - Sibling of Z1 (fix round 4) that the B1 fix (this run, commit 2f97209) left reachable. tableChangesPrecludeReplace treats a body that reads columns it does not name (viewReadsEveryColumn: SELECT *, NATURAL JOIN) as stale only when the plan holds an add-column. The same body over another managed VIEW goes stale when the plan replaces that view in place with an appended column, and nothing checks for it.

Sequence, on meta migrate --from-db: projection P1 owns v_base. A stamped @sql view v_top, declared after it, is SELECT b.*, 1 AS extra FROM v_base b. One change appends a computed field to P1 and edits v_top's body (2 AS extra). pushViewUpdate for v_base: the probe reports live [id, a], expected [id, a, b2], so the plan is a proven replace-view. pushViewUpdate for v_top: columnsMayGrow is true, but changes holds no add-column and no trigger kind, so the probe is asked. It runs against the old v_base, reports [id, a, extra] on both sides, and the plan is a second proven replace-view. The emitter writes both at stage 7 in plan order. CREATE OR REPLACE VIEW v_base succeeds. CREATE OR REPLACE VIEW v_top then expands b.* to [id, a, b2, extra] and Postgres refuses it: cannot change name of view column &#34;extra&#34; to &#34;b2&#34;. This is the apply-time refusal the ruling says is never planned. At base a managed @sql change was always drop + create, so this is a regression for that case. It needs v_base ahead of v_top in the expected views (declaration order); in the other order the replaces apply.

I did not run this (review runs no tests). The Postgres behaviour is the one the Z1 test already pins for a table (view-replace-probe-pg.test.ts, 'a body change beside an added column').

The rest of this class is closed, so one fix ends it:

  • table add-column under a * body: covered (line 1160)
  • type change, drop or rename of a column, tracked table: Pass 2c supersedes the replace
  • the same on an untracked table, and drop-table: covered by B1 (lines 1161-1166)
  • a view the body reads that is dropped and created: the drop is blocked on dropViewCascade, because the dependent is not recreated
  • a new view or column the body reads: the probe fails to compile, so drop + create
  • a view the body names, replaced in place and gaining a column: not covered (this finding)

Fix: at line 1160, when columnsMayGrow, also return true for a queued replace-view whose view name the expected body names (columnIdentifierRe(c.view.name).test(expected.sql)). changes holds it only when it is emitted first, which is the failing order. Add a unit case beside 'a body that reads every column, in a plan that adds a column' in migrate-ts/test/unit/view-replace.test.ts, and a live case in view-replace-probe-pg.test.ts where Postgres refuses the second replace. Add the case to the list at CHANGELOG.md:1006 and docs/features/migrations-and-drift.md:441.

  • ⚠️ server/typescript/packages/migrate-ts/src/diff/index.ts - Fix B1 and C1 with ONE general, conservative rule instead of another per-case guard (the staleness corners keep recurring because the probe sees the schema BEFORE the plan's changes while the emitter runs replace-view AFTER them). RULE: the probe's proof is only trusted when the plan cannot change what the probed body reads. Concretely, a replace-view for a view may be planned from a probe only if NONE of these holds, else plan drop+create: (a) the plan contains any table change that could alter a column of ANY table (add-column, change-column-type/nullable/default, drop-column, rename-column, plus on SQLite-style dialects the rebuild kinds) and the body is an opaque @SQL body or reads columns it does not name (SELECT , NATURAL JOIN, t.), because dependsOn is partial for @SQL bodies; (b) the body reads columns it does not name AND the plan replaces or drops/creates any OTHER view the body could read (view-on-view), or any column-altering change exists. Generated projection and report bodies name every column and carry a complete dependsOn, so for them keep today's behaviour unchanged (probe decides). Keep it in one named helper with a doc comment stating the invariant, and add unit tests beside view-replace.test.ts for: B1 (joined table not in dependsOn gets a type change), C1 (SELECT * over another view that is replaced with an appended column in the same plan), the existing Z1 case, and that a plain unchanged-schema compatible adoption still replaces in place; plus one live Postgres case in view-replace-probe-pg.test.ts for B1 and one for C1 that APPLIES cleanly. Update CHANGELOG/docs sentence on the probe to mention that a body reading columns it does not name is dropped and recreated when the same change alters any table or view.
  • ⚠️ server/typescript/packages/migrate-ts/src/diff/index.ts:1163 - Fix round X7 (this run, commit 7686999) moved a defect. planMayChangeWhatBodyReads returns false for every derived body and reads only the EXPECTED view. A derived view whose LIVE body reads a table the new body no longer reads is replaced in place while the same plan drops or retypes that table's column, and Postgres refuses the table change at apply.

Sequence, on meta migrate --from-db --allow drop-column: a stamped report has dimension label with @of: Week.title, @via: Session.week. The author re-points it to @of: Program.title, @via: Session.program (same column name, both varchar(200)) and removes field Week.title. Pass 2 queues drop-column weeks.title. pushViewUpdate (line 987): the body is not opaque and has no *, so the probe is asked. The new body compiles, the columns are equal, and the plan is a proven replace-view. Pass 2c (line 1216) filters by the expected view's dependsOn = [sessions, programs]. weeks is not in it, so the replace is not superseded. The emitter runs ALTER TABLE &#34;weeks&#34; DROP COLUMN &#34;title&#34; at stage 2, before the replace at stage 7. The live view still reads the column, and Postgres refuses: cannot drop column title of table weeks because other objects depend on it. A retype of that column fails the same way (cannot alter type of a column used by a view or rule), and so does removing the Week entity (cannot drop table weeks because other objects depend on it). The live test at integration-tests/test/view-replace-probe-pg.test.ts:538-556 already pins these three refusals for an @sql body. I did not run this sequence (review runs no tests).

Scope of the regression: at base a managed change to a report view, or to a projection with a computed, first, any, all or collect column, was always drop + create, with the drop at stage 0, so it applied. The previous round's tableChangesPrecludeReplace (commit 2f97209) also covered it, because it checked actual.sql for any view. A projection with only passthrough and count/sum columns already planned this replace at base.

Other sites that state the same wrong invariant:

  • diff/index.ts:1154-1156, comment: "nothing else in the plan reaches it"
  • CHANGELOG.md:1009-1010: "A derived projection or report view lists every table it reads and is not affected"
  • docs/features/migrations-and-drift.md:443-444: same sentence

Remedy: pass actual to planMayChangeWhatBodyReads and return true when a queued change-column-type, drop-column, rename-column or drop-table is on a table outside expected.dependsOn that the live body names (columnIdentifierRe(table).test(actual.sql); the deparsed live body names every table it reads). Add a unit case in migrate-ts/test/unit/view-replace.test.ts and a live case beside view-replace-probe-pg.test.ts:577, and correct the three sentences above.

This needs a decision, not an automatic fix: the recorded X7 ruling says derived bodies keep "probe decides", and this remedy makes one exception to it.

🔧 Fix applied.
8 issues (1 error, 5 warnings, 2 infos) still open:

  • ⚠️ server/typescript/packages/migrate-ts/src/diff/index.ts:991 - Sibling path of Z1 that stays reachable: the probe proves a replace against the schema before the plan's table changes, and Pass 2c only supersedes that replace for tables listed in the view's dependsOn. An @sql view's dependsOn is partial by design: collectSqlDependsOn (codegen-ts/src/projection/build-projection-views.ts:335) records only the host table, the extends anchors and a report's @from table, never a table the opaque body joins. Sequence: a stamped report with an @sql view SELECT s.id, o.amount FROM sales s JOIN orders o ... (@from: Sale, so dependsOn = [sales]). The author edits the body and, in the same change, widens Order.amount from int to long. meta migrate --from-db: Pass 2 queues change-column-type orders.amount; pushViewUpdate probes, the temporary view and the live view both report amount integer, so the plan is replace-view with proven; Pass 2c (line 1186) finds no altered table in dependsOn and leaves it. The emitter runs ALTER TABLE orders ALTER COLUMN amount TYPE bigint at stage 2, before replace-view at stage 7, and Postgres refuses it: cannot alter type of a column used by a view or rule. The same holds for a drop-column on the joined table that the live body reads. This is a regression for a managed @sql change: at base an opaque body had no column list, so a managed change was always drop-view (stage 0) + create-view (stage 7), which applies. An unchanged @sql view over an untracked join already failed at base (the deferred @dependsOn gap, ADR-0043) and adoption was already a replace, so only the managed-change case is new. Generated projection and report bodies carry a complete dependsOn and are not affected. Fix: in pushViewUpdate, do not consult the probe (plan drop + create) when changes already holds a change-column-type or drop-column on a table outside expected.dependsOn whose column the live or expected body names (viewReadsAnyColumn over actual.sql and expected.sql; the deparsed live body names every column it reads). Pass 2 has run by then. Add a unit case beside 'a body that reads every column, in a plan that adds a column' in view-replace.test.ts, and a live case in view-replace-probe-pg.test.ts where Postgres refuses the ALTER while the view stands.
  • ℹ️ CHANGELOG.md:1074 - For the record, not a defect: D6 is closed by documentation and a pinning test, not by a behavior change. fields() on an object.report still returns []; docs/features/reporting.md:276-283 points to reportReadModel(report, root).fields() instead, and server/typescript/packages/metadata/test/report-shape.test.ts:668 pins both. I checked the second half of D6: enforce is already an accessor on MetaReferenceIdentity (metadata/src/core/identity/meta-identity.ts:100), which referenceIdentities() returns, so that half was never a code defect. The intent sanctions a docs-only answer explicitly for G5 only; I read D6's docs answer as a deliberate choice that survived earlier rounds, so I am not raising it as an intent contradiction.
  • ⚠️ server/typescript/packages/migrate-ts/src/diff/index.ts:1160 - Sibling of Z1 (fix round 4) that the B1 fix (this run, commit 2f97209) left reachable. tableChangesPrecludeReplace treats a body that reads columns it does not name (viewReadsEveryColumn: SELECT *, NATURAL JOIN) as stale only when the plan holds an add-column. The same body over another managed VIEW goes stale when the plan replaces that view in place with an appended column, and nothing checks for it.

Sequence, on meta migrate --from-db: projection P1 owns v_base. A stamped @sql view v_top, declared after it, is SELECT b.*, 1 AS extra FROM v_base b. One change appends a computed field to P1 and edits v_top's body (2 AS extra). pushViewUpdate for v_base: the probe reports live [id, a], expected [id, a, b2], so the plan is a proven replace-view. pushViewUpdate for v_top: columnsMayGrow is true, but changes holds no add-column and no trigger kind, so the probe is asked. It runs against the old v_base, reports [id, a, extra] on both sides, and the plan is a second proven replace-view. The emitter writes both at stage 7 in plan order. CREATE OR REPLACE VIEW v_base succeeds. CREATE OR REPLACE VIEW v_top then expands b.* to [id, a, b2, extra] and Postgres refuses it: cannot change name of view column &#34;extra&#34; to &#34;b2&#34;. This is the apply-time refusal the ruling says is never planned. At base a managed @sql change was always drop + create, so this is a regression for that case. It needs v_base ahead of v_top in the expected views (declaration order); in the other order the replaces apply.

I did not run this (review runs no tests). The Postgres behaviour is the one the Z1 test already pins for a table (view-replace-probe-pg.test.ts, 'a body change beside an added column').

The rest of this class is closed, so one fix ends it:

  • table add-column under a * body: covered (line 1160)
  • type change, drop or rename of a column, tracked table: Pass 2c supersedes the replace
  • the same on an untracked table, and drop-table: covered by B1 (lines 1161-1166)
  • a view the body reads that is dropped and created: the drop is blocked on dropViewCascade, because the dependent is not recreated
  • a new view or column the body reads: the probe fails to compile, so drop + create
  • a view the body names, replaced in place and gaining a column: not covered (this finding)

Fix: at line 1160, when columnsMayGrow, also return true for a queued replace-view whose view name the expected body names (columnIdentifierRe(c.view.name).test(expected.sql)). changes holds it only when it is emitted first, which is the failing order. Add a unit case beside 'a body that reads every column, in a plan that adds a column' in migrate-ts/test/unit/view-replace.test.ts, and a live case in view-replace-probe-pg.test.ts where Postgres refuses the second replace. Add the case to the list at CHANGELOG.md:1006 and docs/features/migrations-and-drift.md:441.

  • ⚠️ server/typescript/packages/migrate-ts/src/diff/index.ts - Fix B1 and C1 with ONE general, conservative rule instead of another per-case guard (the staleness corners keep recurring because the probe sees the schema BEFORE the plan's changes while the emitter runs replace-view AFTER them). RULE: the probe's proof is only trusted when the plan cannot change what the probed body reads. Concretely, a replace-view for a view may be planned from a probe only if NONE of these holds, else plan drop+create: (a) the plan contains any table change that could alter a column of ANY table (add-column, change-column-type/nullable/default, drop-column, rename-column, plus on SQLite-style dialects the rebuild kinds) and the body is an opaque @SQL body or reads columns it does not name (SELECT , NATURAL JOIN, t.), because dependsOn is partial for @SQL bodies; (b) the body reads columns it does not name AND the plan replaces or drops/creates any OTHER view the body could read (view-on-view), or any column-altering change exists. Generated projection and report bodies name every column and carry a complete dependsOn, so for them keep today's behaviour unchanged (probe decides). Keep it in one named helper with a doc comment stating the invariant, and add unit tests beside view-replace.test.ts for: B1 (joined table not in dependsOn gets a type change), C1 (SELECT * over another view that is replaced with an appended column in the same plan), the existing Z1 case, and that a plain unchanged-schema compatible adoption still replaces in place; plus one live Postgres case in view-replace-probe-pg.test.ts for B1 and one for C1 that APPLIES cleanly. Update CHANGELOG/docs sentence on the probe to mention that a body reading columns it does not name is dropped and recreated when the same change alters any table or view.
  • ⚠️ server/typescript/packages/migrate-ts/src/diff/index.ts:1163 - Fix round X7 (this run, commit 7686999) moved a defect. planMayChangeWhatBodyReads returns false for every derived body and reads only the EXPECTED view. A derived view whose LIVE body reads a table the new body no longer reads is replaced in place while the same plan drops or retypes that table's column, and Postgres refuses the table change at apply.

Sequence, on meta migrate --from-db --allow drop-column: a stamped report has dimension label with @of: Week.title, @via: Session.week. The author re-points it to @of: Program.title, @via: Session.program (same column name, both varchar(200)) and removes field Week.title. Pass 2 queues drop-column weeks.title. pushViewUpdate (line 987): the body is not opaque and has no *, so the probe is asked. The new body compiles, the columns are equal, and the plan is a proven replace-view. Pass 2c (line 1216) filters by the expected view's dependsOn = [sessions, programs]. weeks is not in it, so the replace is not superseded. The emitter runs ALTER TABLE &#34;weeks&#34; DROP COLUMN &#34;title&#34; at stage 2, before the replace at stage 7. The live view still reads the column, and Postgres refuses: cannot drop column title of table weeks because other objects depend on it. A retype of that column fails the same way (cannot alter type of a column used by a view or rule), and so does removing the Week entity (cannot drop table weeks because other objects depend on it). The live test at integration-tests/test/view-replace-probe-pg.test.ts:538-556 already pins these three refusals for an @sql body. I did not run this sequence (review runs no tests).

Scope of the regression: at base a managed change to a report view, or to a projection with a computed, first, any, all or collect column, was always drop + create, with the drop at stage 0, so it applied. The previous round's tableChangesPrecludeReplace (commit 2f97209) also covered it, because it checked actual.sql for any view. A projection with only passthrough and count/sum columns already planned this replace at base.

Other sites that state the same wrong invariant:

  • diff/index.ts:1154-1156, comment: "nothing else in the plan reaches it"
  • CHANGELOG.md:1009-1010: "A derived projection or report view lists every table it reads and is not affected"
  • docs/features/migrations-and-drift.md:443-444: same sentence

Remedy: pass actual to planMayChangeWhatBodyReads and return true when a queued change-column-type, drop-column, rename-column or drop-table is on a table outside expected.dependsOn that the live body names (columnIdentifierRe(table).test(actual.sql); the deparsed live body names every table it reads). Add a unit case in migrate-ts/test/unit/view-replace.test.ts and a live case beside view-replace-probe-pg.test.ts:577, and correct the three sentences above.

This needs a decision, not an automatic fix: the recorded X7 ruling says derived bodies keep "probe decides", and this remedy makes one exception to it.

  • 🚨 server/typescript/packages/migrate-ts/src/diff/index.ts - Maintainer decision on E1: FIX as the reviewer's remedy. The X7 ruling ('probe decides' for derived bodies) stands, with this ONE stated exception, because the probe only proves the NEW body; it cannot see that the LIVE body still reads a table the same plan alters. Pass actual (the live view descriptors, with their live body text/dependencies) to planMayChangeWhatBodyReads and return true (so the plan is drop+create, the probe is not trusted) when a queued change-column-type, drop-column, rename-column or drop-table targets a table that is OUTSIDE expected.dependsOn but that the LIVE body names (use the live body's own table references: pg view body from the introspected view, or pg_depend/pg_rewrite dependency info captured at introspection if that is more exact than a textual match; a textual over-report is acceptable, an under-report is not). ADD: a unit case in view-replace.test.ts and a LIVE Postgres case in view-replace-probe-pg.test.ts covering all three refusals on a DERIVED view (drop column, retype, drop table) EXECUTED against Postgres (apply the emitted SQL, assert it applies cleanly), not only planned. CORRECT the three wrong sentences: the planMayChangeWhatBodyReads doc comment in diff/index.ts (it says a derived body lists every table it reads and the probe decides; add the live-body exception), the CHANGELOG sentence, and docs/features/migrations-and-drift.md. SIBLING CHECK, complete in THIS round: (1) rename-table: confirm with a LIVE Postgres test that a plan renaming a table the LIVE view body reads (outside dependsOn) plus a proven replace-view applies cleanly (Postgres views follow the table by OID, so I expect no refusal; if a refusal exists, fix it the same way), and that rename-column on such a table is handled by the same exception (rename-column is in the exception list above, though Postgres allows it, over-recreating is acceptable). (2) MySQL: migrate-ts has no mysql Dialect (postgres|sqlite|d1 only): say so in your summary, and make no change. (3) SQLite/D1: there is no probe there; replace-view already emits DROP VIEW + CREATE VIEW (emit/sqlite.ts) so no refusal-by-replace exists; but check with a REAL SQLite database (bun:sqlite) whether a table rebuild or DROP COLUMN on a table the LIVE view reads (outside dependsOn) fails at apply, and if it does, extend the same live-body check to the sqlite/d1 recreate path (recreateViewsDependingOnChangedTables / d1-cascade) so the view is dropped before the table change and recreated after; add the executed test. State in your final summary exactly what you checked and the result for each of rename-table, mysql, sqlite, d1. Make the round complete: this is the last validation run.
  • ⚠️ server/typescript/packages/migrate-ts/src/diff/index.ts:1244 - Sibling left by fix round X8 (commit 9964716). X8 made SQLite and D1 drop and recreate a view for every table its LIVE body names, including an unchanged @sql body that joins a table outside dependsOn. The same case on Postgres is still not handled, and the comment at lines 1240-1243 says it is ("On Postgres the same case is settled when the view is planned (pushViewUpdate)"). pushViewUpdate runs only for a view whose definition changed or is unstamped. A stamped view with an equal fingerprint is skipped at line 926, and the live-body check at line 1244 is guarded by !columnScoped, which is false on Postgres.

Sequence, on meta migrate --from-db (or offline): a stamped @sql view SELECT g.id, (SELECT count(*) FROM scores s WHERE s.points &gt; 0) AS scored FROM games g, with dependsOn = [games] (ADR-0043: a joined table is not tracked). The author widens Score.points from int to long and does not touch the view. Pass 2 queues change-column-type scores.points. Pass 2b plans nothing for the view. Pass 2c: tracked.filter gives [], the live-body loop is skipped, so no view change. At apply, ALTER TABLE &#34;scores&#34; ALTER COLUMN &#34;points&#34; TYPE BIGINT is refused: cannot alter type of a column used by a view or rule (the refusal view-replace-probe-pg.test.ts:538-556 already pins for a standing view). I traced the plan with an ad-hoc diff() call: Postgres gives [change-column-type], SQLite gives [change-column-type, drop-view, create-view].

This is not a regression: base planned the same on Postgres, and ADR-0043 records the untracked join as a known limit. It is reported because X8 closed this exact case on the other two dialects and the comment now states it is closed on Postgres. Only a retype reaches it (a drop needs a body change, which is handled; Postgres allows a rename under a view). A derived view is not affected: an unchanged derived body has a complete dependsOn.

The remedy, not the defect, needs a decision, because it extends the change beyond D2/D4 and the X8 ruling. Option 1: in Pass 2c on Postgres, for a view with prior present, also add each altered table outside tracked that liveViewReadsTable(prior, liveName(t)) names, kept column-scoped (recreate only when viewReadsAnyColumn(prior.sql, cols, ...)), with a unit case in diff-view-recreate-columns.test.ts and a live case in view-replace-probe-pg.test.ts that applies. Option 2: leave the behaviour, correct the comment at lines 1240-1243 to say Postgres covers only a view whose definition also changes, and state the limit in docs/features/migrations-and-drift.md beside the SQLite/D1 paragraph.

  • ℹ️ CHANGELOG.md:1042 - Fix round X8 added the bullet at line 1018 ("SQLite and D1 drop a view before changing any table its stored definition reads ... It now is for every table the definition in the database names as well") and left the older bullet at line 1042 in the same entry: "SQLite and D1 are unchanged." The two contradict each other. The older bullet is about the replace rule only. Fix, docs only: reword line 1042 to say the replace rule does not apply to SQLite and D1 (neither has CREATE OR REPLACE VIEW; a replaced view is written as DROP VIEW and CREATE VIEW there, as before). docs/features/migrations-and-drift.md already words this correctly and needs no change.

🔧 Fix applied.
1 info still open:

  • ℹ️ CHANGELOG.md:1074 - For the record, not a defect: D6 is closed by documentation and a pinning test, not by a behavior change. fields() on an object.report still returns []; docs/features/reporting.md:276-283 points to reportReadModel(report, root).fields() instead, and server/typescript/packages/metadata/test/report-shape.test.ts:668 pins both. I checked the second half of D6: enforce is already an accessor on MetaReferenceIdentity (metadata/src/core/identity/meta-identity.ts:100), which referenceIdentities() returns, so that half was never a code defect. The intent sanctions a docs-only answer explicitly for G5 only; I read D6's docs answer as a deliberate choice that survived earlier rounds, so I am not raising it as an intent contradiction.
✅ **Test** - passed

✅ No issues found.

  • Live validation: ✅ go - 4 of 7 scenarios driven live against the product
Scenario Result Live Evidence
Postgres probe: replace-view only when the live probe proves legality (retypes, appended columns, name/position checks) ✅ pass live view-replace-probe-pg.test.ts (103 tests with view-adopt-typed-pg.test.ts), run against a containerised Postgres
Postgres view lifecycle and drift: views recreated around table changes, reports and projections keep working ✅ pass live view-lifecycle-pg.test.ts, view-recreate-drift-pg.test.ts, report-views-pg.test.ts
SQLite view drop/recreate around table rebuilds and drift ✅ pass live view-recreate-drift-sqlite.test.ts, view-lifecycle-sqlite.test.ts
D1 view recreated over a rebuilt table (cascade case) ✅ pass live d1-243-view-over-rebuilt-table.test.ts
Planner unit behaviour for view replace and recreate decisions ⏸️ untested no Unit tests only, not a live product run. Supporting evidence, not counted as live validation.
Full server regression suite ⏸️ untested no Regression run of the test suite, not a live product run. Reported as 10761 pass, 0 fail, 50 skip, but not counted as live validation.
MySQL view path (intent: check same shape) ⏸️ untested no migrate-ts has no mysql Dialect (postgres | sqlite | d1 only), so there is no path to drive.
  • scripts/ci-local.sh --only ts-fast --only ts-unit --strict-toolchains
  • bun test test/view-replace-probe-pg.test.ts test/view-adopt-typed-pg.test.ts (integration-tests) — 103 pass, 0 fail, live Postgres container
  • bun test test/view-recreate-drift-sqlite.test.ts test/view-lifecycle-sqlite.test.ts test/d1-243-view-over-rebuilt-table.test.ts test/view-lifecycle-pg.test.ts test/view-recreate-drift-pg.test.ts test/report-views-pg.test.ts (integration-tests) — 53 pass, 0 fail
  • bun test test/unit (migrate-ts) — 578 pass, 0 fail (not live)
  • bun test (server/typescript, full suite) — 10761 pass, 0 fail, 50 skip, 1041 files, log ~/.no-mistakes/evidence/01M4NZ4P9PWQPKZF800CGBQHGP/full-server-suite.log (not live)
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

Flyway history filter (migrate, verify, gen advice), view replace legality decided by a rolled-back Postgres probe, --allow hints, view recreate scope, report @via dimension nullability in every port, Kotlin <Report>Names, oracle CLI providers, prerelease-link and prerelease:publish fixes, doc-writer timestamps removed.
…reads changed tables Postgres: a view is dropped and created, not replaced, when the plan retypes, drops or renames a column of, or drops, a table outside dependsOn that the live body names (X8 exception to X7; a renamed table is matched by its old name). Sibling checks: - rename-table: live Postgres test; no refusal, the proven replace-view applies and the view keeps its OID. No change. - rename-column: covered by the same exception; live Postgres test, drop + create applies. - mysql: migrate-ts Dialect is postgres | sqlite | d1; no MySQL dialect. No change. - sqlite: real bun:sqlite, statements applied one at a time. DROP COLUMN and a table rebuild both fail while a view reading the table stands. Fixed in Pass 2c for every table the stored view body names; executed test added. - d1: same engine, in one transaction with foreign keys on. Native path and the foreign-key cascade both failed; both fixed, including a view the migration drops; executed test added. Not covered: an @Unmanaged live view reading a cascade-rebuilt table is left alone
…ws over retyped untracked tables Pass 2c now covers Postgres: an unchanged view is dropped before and created after a table change when its stored body names an altered table outside dependsOn and the altered column (column-scoped). Unit case and an executed live Postgres case added; both fail without the fix. F2: the CHANGELOG bullet "SQLite and D1 are unchanged" now says the replace rule does not apply to SQLite and D1. MySQL: migrate-ts Dialect is postgres | sqlite | d1; no MySQL path exists, no change
@dmealing
dmealing merged commit 00d056b into main Oct 11, 2026
1 check passed
@dmealing
dmealing deleted the fm/mo-1-1-0-pl-fixes branch October 11, 2026 19:16
dmealing added a commit that referenced this pull request Oct 11, 2026
Brings in #429 and #430. One conflict, in CHANGELOG.md: both sides appended
entries to the end of the [1.1.0] Fixed list, and both are kept.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant